package com.comcast.util;

import static java.lang.String.format;

import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.Set;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.aop.Advisor;
import org.springframework.aop.framework.Advised;
import org.springframework.beans.BeanWrapper;
import org.springframework.beans.BeanWrapperImpl;
import org.springframework.beans.BeansException;
import org.springframework.beans.MutablePropertyValues;
import org.springframework.beans.PropertyValue;
import org.springframework.beans.factory.BeanDefinitionStoreException;
import org.springframework.beans.factory.BeanFactory;
import org.springframework.beans.factory.BeanIsAbstractException;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.config.BeanDefinition;
import org.springframework.beans.factory.config.BeanDefinitionHolder;
import org.springframework.beans.factory.config.BeanDefinitionVisitor;
import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
import org.springframework.beans.factory.support.ChildBeanDefinition;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.util.Assert;
import org.springframework.util.ObjectUtils;

import com.comcast.util.ConfigurationManagerImpl;
import com.comcast.util.PropertiesReloadedAware;
import com.comcast.util.ReloadableConfigurationListener;

/**
 * Allows for properties reload at runtime. All the place holders will be 
 * set to the new values if changed, for both regular POJOs and proxies.
 * 
 * <p><b>NOTE:</b> Place holders in nested beans, set, list and map will <i>not</i> be 
 * resolved with reloaded properties. Please avoid using place holders in these
 * types if you want to leverage properties reloading mechanism.
 * 
 * @author Charles Zhao
 */
public class ReloadablePropertyPlaceholderConfigurer extends PropertyPlaceholderConfigurer 
				implements ReloadableConfigurationListener, InitializingBean, ApplicationContextAware {
    private static Log log = LogFactory.getLog(ReloadablePropertyPlaceholderConfigurer.class);
     
	private String placeholderPrefix = DEFAULT_PLACEHOLDER_PREFIX;

	private String placeholderSuffix = DEFAULT_PLACEHOLDER_SUFFIX;

	private int systemPropertiesMode = SYSTEM_PROPERTIES_MODE_FALLBACK;
	
	private boolean ignoreUnresolvablePlaceholders;
	
	private String beanName;
	
	private BeanFactory beanFactory;
	
	private String beanNameBeingVisited;
	private String propertyNameBeingVisited;
	
	private Map<String, List<OriginalBeanProperty>> placeholdersToBeanPropertiesMap = new HashMap<String, List<OriginalBeanProperty>>();

	private ApplicationContext applicationContext;
	
    public void setSystemPropertiesMode(int systemPropertiesMode) {
		this.systemPropertiesMode = systemPropertiesMode;
	}

	public void setPlaceholderSuffix(String placeholderSuffix) {
		this.placeholderSuffix = placeholderSuffix;
	}
   
    public void setPlaceholderPrefix(String placeholderPrefix) {
		this.placeholderPrefix = placeholderPrefix;
	}

	public void setIgnoreUnresolvablePlaceholders(
			boolean ignoreUnresolvablePlaceholders) {
		this.ignoreUnresolvablePlaceholders = ignoreUnresolvablePlaceholders;
	}
	
	@Override
	public void setBeanName(String beanName) {
		this.beanName = beanName;
	}

	@Override
	public void setBeanFactory(BeanFactory beanFactory) {
		this.beanFactory = beanFactory;
	}	
	
	/**
	 * Resolves the place holders in a string value. We don't need to support nested place holders as
	 * {@link PropertyPlaceholderConfigurer#parseStringValue(String, Properties, Set)}
	 * does, because it is already taken care of by Commons Configuration.
	 */
	protected String resolvePlaceholders(String originalValue, Properties props) throws BeanDefinitionStoreException {
		StringBuilder result = new StringBuilder(originalValue);
		
		OriginalBeanProperty originalBeanProperty = null;
		if ( this.beanNameBeingVisited != null && this.propertyNameBeingVisited != null ) {
			originalBeanProperty = new OriginalBeanProperty(this.beanNameBeingVisited, this.propertyNameBeingVisited, originalValue);
		}
		
		int startIndex = originalValue.indexOf(this.placeholderPrefix);
		while (startIndex != -1) {
			int endIndex = result.toString().indexOf(
			    this.placeholderSuffix, startIndex + this.placeholderPrefix.length());
			if (endIndex != -1) {
				String placeholder = result.substring(startIndex + this.placeholderPrefix.length(), endIndex);
				saveBeanPropertyWithPlaceholders(placeholder, originalBeanProperty);
				String propVal = resolvePlaceholder(placeholder, props, this.systemPropertiesMode);
				if (propVal != null) {
					result.replace(startIndex, endIndex + this.placeholderSuffix.length(), propVal);
					if (log.isDebugEnabled()) {
						log.debug("Resolved placeholder '" + placeholder + "'");
					}
					startIndex = result.toString().indexOf(this.placeholderPrefix, startIndex + propVal.length());
				} else if (this.ignoreUnresolvablePlaceholders) {
					if (log.isWarnEnabled()) {
						log.warn("Ignored unresolved placeholder '" + placeholder + "'");
					}
					startIndex = result.toString().indexOf(this.placeholderPrefix, endIndex + this.placeholderSuffix.length());
				} else {
					throw new BeanDefinitionStoreException("Could not resolve placeholder '" + placeholder + "'");
				}
			}
			else {
				startIndex = -1;
			}
		}
	
		return result.toString();
	}

	private void saveBeanPropertyWithPlaceholders(String placeholder, OriginalBeanProperty originalBeanProperty) {
		if ( originalBeanProperty == null ) {
			return;
		}
		List<OriginalBeanProperty> beanPropertyList = this.placeholdersToBeanPropertiesMap.get(placeholder);
		if ( beanPropertyList == null ) {
			beanPropertyList = new ArrayList<OriginalBeanProperty>();
			this.placeholdersToBeanPropertiesMap.put(placeholder, beanPropertyList);
		}
		beanPropertyList.add(originalBeanProperty);
		if ( log.isDebugEnabled() ) {
			log.debug(format("Saved %s for place holder '%s'", originalBeanProperty.toString(), placeholder));
		}
	}

	/**
	 * This method is copied from Spring's {@link PropertyPlaceholderConfigurer} and 
	 * is changed accordingly to support reloadable properties.
	 */
	@Override	
	protected void processProperties(ConfigurableListableBeanFactory beanFactoryToProcess, Properties props) throws BeansException {
		BeanDefinitionVisitor visitor = new PlaceholderBeanDefinitionVisitor(props);
		String[] beanNames = beanFactoryToProcess.getBeanDefinitionNames();
		Map<String, List<String>> beanInheritancesMap = new HashMap<String, List<String>>();
		for (int i = 0; i < beanNames.length; i++) {
			if (!(beanNames[i].equals(this.beanName) && beanFactoryToProcess.equals(this.beanFactory))) { 
				String thisBeanName = beanNames[i];
				BeanDefinition bd = beanFactoryToProcess.getBeanDefinition(thisBeanName);
				
				if ( bd instanceof ChildBeanDefinition ) {
					addBeanInheritanceToMap(((ChildBeanDefinition)bd).getParentName(), thisBeanName, beanInheritancesMap);
				}
				try {
					this.beanNameBeingVisited = thisBeanName;
					visitor.visitBeanDefinition(bd);
				} catch (BeanDefinitionStoreException ex) {
					throw new BeanDefinitionStoreException(bd.getResourceDescription(), thisBeanName, ex.getMessage());
				} finally {
					this.beanNameBeingVisited = null;
				}
			}
		}
		
		deriveBeanProperties(beanInheritancesMap);
	}

	private void addBeanInheritanceToMap(String parentName, String thisBeanName, Map<String, List<String>> beanInheritancesMap) {
		List<String> childBeanNames = beanInheritancesMap.get(parentName);
		if ( childBeanNames == null ) {
			childBeanNames = new ArrayList<String>();
			beanInheritancesMap.put(parentName, childBeanNames);
		}
		if ( log.isDebugEnabled() ) {
			log.debug(format("Found bean inherience. Parent bean '%s', child bean '%s'.", parentName, thisBeanName));
		}
		childBeanNames.add(thisBeanName);
	}	

	private void deriveBeanProperties(Map<String, List<String>> beanInheriencesMap) {
		for (String placeholder : placeholdersToBeanPropertiesMap.keySet() ) {
			List<OriginalBeanProperty> beanProperties = placeholdersToBeanPropertiesMap.get(placeholder);
			List<OriginalBeanProperty> derivedBeanProperties = new ArrayList<OriginalBeanProperty>();
			for ( OriginalBeanProperty beanProperty : beanProperties) {
				deriveBeanProperty(beanInheriencesMap, placeholder,
						beanProperty, derivedBeanProperties);
			}
			beanProperties.addAll(derivedBeanProperties);
		}
	}

	private void deriveBeanProperty(Map<String, List<String>> beanInheriencesMap, String placeholder, OriginalBeanProperty beanProperty,
			List<OriginalBeanProperty> derivedBeanProperties) {
		String beanName = beanProperty.getBeanName();
		if ( beanInheriencesMap.containsKey(beanName) ) {
			for ( String derivedBeanName : beanInheriencesMap.get(beanName) ) {
				OriginalBeanProperty derivedBeanProperty = new OriginalBeanProperty(derivedBeanName, beanProperty.getPropertyName(), beanProperty.getOriginalValue());
				derivedBeanProperties.add(derivedBeanProperty);
				deriveBeanProperty(beanInheriencesMap, placeholder, derivedBeanProperty, derivedBeanProperties);
				if ( log.isDebugEnabled() ) {
					log.debug(format("Saved %s for place holder '%s'", derivedBeanProperty.toString(), placeholder));
				}							
			}
		}
	}

	/** 
	 * This method is invoked by {@link ConfigurationManagerImpl} when there are database properties 
	 * changes reloaded.
	 * 
	 * <p>Resolves place holders in spring context with updated property values.
	 * 
	 * @see com.comcast.core.framework.configuration.ReloadableConfigurationListener#propertiesReloaded(java.util.Properties)
	 */
	public void propertiesReloaded(Properties updatedProperties) {
		if ( log.isInfoEnabled() ) {
			log.info(format("Resolving place holders in Spring context with updated properties. Updated properties: %s", updatedProperties.keySet()));
		}
		
		Properties newProperties = getNewProperties();
		if ( newProperties == null ) return;
		Map<String, Set<OriginalBeanProperty>> beansToBeUpdated = getBeansToBeUpdated(updatedProperties);
		
		String[] allBeanNames = applicationContext.getBeanDefinitionNames();
		for ( String beanName : allBeanNames ) {
			Object bean = getSpringBean(beanName);
			if ( bean != null ) {
				if ( bean instanceof Advised ) {
					updateAdvisedBean((Advised)bean, beanName, beansToBeUpdated, updatedProperties, newProperties);
				} else {
					updateTheBean(bean, beansToBeUpdated, updatedProperties, beanName, newProperties);
				}
			}
		}
	}
	
	public void updateAdvisedBean(Advised advisedBean, String beanName, Map<String, Set<OriginalBeanProperty>> beansToBeUpdated, Properties updatedProperties, Properties newProperties) {
		Object target = getTargetFromAdvised(advisedBean);
		if ( target != null ) {
			//This bean is intercepted by aspects.
			if ( target instanceof Advised ) {
				updateAdvisedBean((Advised)target, beanName, beansToBeUpdated, updatedProperties, newProperties);
			} else {
				updateTheBean(target, beansToBeUpdated, updatedProperties, beanName, newProperties);
			}
		} else {
			//This bean is a proxy created by a FactoryBean
			Advisor[] advisors = advisedBean.getAdvisors();
			for ( Advisor advisor : advisors ) {
				if ( advisor instanceof Advised ) {
					updateAdvisedBean((Advised)target, beanName, beansToBeUpdated, updatedProperties, newProperties);
				} else {
					updateTheBean(advisor.getAdvice(), beansToBeUpdated, updatedProperties, beanName, newProperties);
				}
			}
		}		
	}

	private Object getSpringBean(String beanName) {
		try {
			return applicationContext.getBean(beanName);
		} catch (BeanIsAbstractException e) {
			//Ignore abstract bean.
			return null;
		}
	}
	
	private Object getTargetFromAdvised(Advised advised) {
		try {
			return advised.getTargetSource().getTarget();
		} catch (Exception e) {
			return null;
		}
	}

	private void updateTheBean(Object bean, Map<String, Set<OriginalBeanProperty>> beansToBeUpdated,
			Properties updatedProperties, String beanName, Properties newProperties) {
		Set<OriginalBeanProperty> propertiesToBeUpdated = beansToBeUpdated.get(beanName);
		if ( propertiesToBeUpdated != null && propertiesToBeUpdated.size() > 0 ) {
			updateBeanWithReloadedProperties(bean, propertiesToBeUpdated, newProperties);
		} 
		if ( bean instanceof PropertiesReloadedAware ) {
			try {
				((PropertiesReloadedAware)bean).propertiesReloaded(updatedProperties);
				if ( log.isInfoEnabled() ) {
					log.info(format("Invoked propertiesReloaded method of bean '%s'.", beanName));
				}						
			} catch (Exception e) {
				if ( log.isWarnEnabled() ) {
					log.warn(format("Error occurred when invoking propertiesReloaded method of bean '%s' due to: %s. Ignored.", beanName, e.getMessage()));
				}						
			}
		}
	}

	private Properties getNewProperties() {
		Properties newProperties = null;
		try {
			newProperties = mergeProperties();
		} catch (IOException ex) {
			log.warn("Could not merge properties from the updated configuration manager. Failed to reload the properties for place holders in spring configuration.", ex);			
		}
		return newProperties;
	}

	private Map<String, Set<OriginalBeanProperty>> getBeansToBeUpdated(Properties updatedProperties) {
		Map<String, Set<OriginalBeanProperty>> beansToBeUpdated = new HashMap<String, Set<OriginalBeanProperty>>();
		for ( Object updatedPropertyName : updatedProperties.keySet() ) {
			List<OriginalBeanProperty> orignialBeanProperties = placeholdersToBeanPropertiesMap.get(updatedPropertyName);
			if ( orignialBeanProperties != null ) {
				for ( OriginalBeanProperty beanProperty : orignialBeanProperties) {
					String beanName = beanProperty.getBeanName();
					Set<OriginalBeanProperty> propertiesToBeUpdated = beansToBeUpdated.get(beanName);
					if ( propertiesToBeUpdated == null ) {
						propertiesToBeUpdated = new HashSet<OriginalBeanProperty>();
						beansToBeUpdated.put(beanName, propertiesToBeUpdated);
					}
					propertiesToBeUpdated.add(beanProperty);
				}
			}
		}
		return beansToBeUpdated;
	}
	
	
	private void updateBeanWithReloadedProperties(Object bean, Set<OriginalBeanProperty> propertiesToBeUpdated, Properties newProperties) {
		BeanWrapper beanWrapper = new BeanWrapperImpl(bean);
		for ( OriginalBeanProperty originalBeanProperty : propertiesToBeUpdated ) {
			String propertyName = originalBeanProperty.getPropertyName();
			String originalValue = originalBeanProperty.getOriginalValue();
			String newParsedValue = super.parseStringValue(originalValue, newProperties, new HashSet<String>());
			try {
				beanWrapper.setPropertyValue(propertyName, newParsedValue);
				if ( log.isInfoEnabled() ) {
					log.info(format("Updated property '%s' of bean '%s' to newly reloaded value '%s'. ", propertyName, originalBeanProperty.getBeanName(), newParsedValue));
				}
			} catch (Exception e) {
				if ( log.isWarnEnabled() ) {
					logger.warn(format("Could not update property '%s' of bean '%s' to newly reloaded value '%s' due to: %s. Ignored.", propertyName, originalBeanProperty.getBeanName(), newParsedValue, e.getMessage()));
				}
			}	
		}
	}
	
	/**
	 * Visits bean definition and resolves the place holders in all the string values.
	 */
	private class PlaceholderBeanDefinitionVisitor extends BeanDefinitionVisitor {
		private final Properties props;
		private int nestedLevel = 0;
		private String topLevelBeanName;

		public PlaceholderBeanDefinitionVisitor(Properties props) {
			this.props = props;
		}

	    @Override
		public void visitBeanDefinition(BeanDefinition beanDefinition) {
	    	//This is to make sure nested beans will not add any problematic or conflicting OrginalBeanProperty 
        	//for its parent bean and cause runtime issues.		   
    		nestedLevel++;
    		if ( nestedLevel > 1 && beanNameBeingVisited != null) {
    			topLevelBeanName = beanNameBeingVisited;
    			beanNameBeingVisited = null;
    		}	
    		
	    	try {
	    		super.visitBeanDefinition(beanDefinition);
	    	} finally {
	    		nestedLevel--;
	    		if ( nestedLevel == 1) {
	    			beanNameBeingVisited = topLevelBeanName;
	    		}
	    	}
		}

		protected void visitPropertyValues(MutablePropertyValues pvs) {
	        PropertyValue[] pvArray = pvs.getPropertyValues();
	        for (PropertyValue pv: pvArray) {
	          propertyNameBeingVisited = pv.getName();
	          try {
	        	Object value = pv.getValue();
		    	//This is to make sure unsupported types (map, list and set)
	        	//will not add any problematic or conflicting OrginalBeanProperty 
	        	//for its parent bean and cause runtime issues.	        	
	        	if ( isUnsupportedValue(value) ) {
	        		propertyNameBeingVisited = null;
	        	}
	        	
	            Object newVal = resolveValue(value);
	            if (!ObjectUtils.nullSafeEquals(newVal, pv.getValue())) {
	              pvs.addPropertyValue(pv.getName(), newVal);
	            }
	          } finally {
	        	  propertyNameBeingVisited = null;
	          }
	        }
	      }
	    
		private boolean isUnsupportedValue(Object value) {
			return value instanceof BeanDefinition ||
					value instanceof BeanDefinitionHolder ||
					value instanceof Set ||
					value instanceof List ||
					value instanceof Map;
		}

		protected String resolveStringValue(String orignialValue) throws BeansException {
			return resolvePlaceholders(orignialValue, this.props);
		}
	}    
	
	/**
	 * Represents a string value bean property with original value before place holders
	 * are resolved.
	 */
	private static class OriginalBeanProperty {
		private String beanName;
		private String propertyName;
		private String originalValue;
		
		public OriginalBeanProperty(String beanName, String propertyName, String originalValue) {
			this.beanName = beanName;
			this.propertyName = propertyName;
			this.originalValue = originalValue;
		}
		
		public String getBeanName() {
			return beanName;
		}
		public void setBeanName(String beanName) {
			this.beanName = beanName;
		}
		public String getPropertyName() {
			return propertyName;
		}
		public void setPropertyName(String propertyName) {
			this.propertyName = propertyName;
		}
		public String getOriginalValue() {
			return originalValue;
		}
		public void setOriginalValue(String originalValue) {
			this.originalValue = originalValue;
		}

		@Override
		public int hashCode() {
			final int prime = 31;
			int result = 1;
			result = prime * result + ((beanName == null) ? 0 : beanName.hashCode());
			result = prime * result + ((propertyName == null) ? 0 : propertyName.hashCode());
			return result;
		}

		@Override
		public boolean equals(Object obj) {
			if (this == obj) return true;
			if (obj == null) return false;
			if (getClass() != obj.getClass()) return false;
			
			OriginalBeanProperty other = (OriginalBeanProperty) obj;
			if (beanName == null) {
				if (other.beanName != null) return false;
			} else if (!beanName.equals(other.beanName)) {
				return false;
			}
			
			if (propertyName == null) {
				if (other.propertyName != null) return false;
			} else if (!propertyName.equals(other.propertyName)) {
				return false;
			}
			return true;
		}
		
		public String toString() {
			return format("OriginalBeanProperty@%s[beanName='%s', propertyName='%s', originalValue='%s']", this.hashCode(), this.beanName, this.propertyName, this.originalValue);
		}
	}

	public void afterPropertiesSet() throws Exception {
		Assert.notNull(configurationManager, "ConfigurationManager can not be null.");
		((ConfigurationManagerImpl)configurationManager).addReloadableConfigurationListener(this);
	}

	public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
		this.applicationContext = applicationContext;
	}
}
